iT邦幫忙

2024 iThome 鐵人賽

DAY 8
1
佛心分享-SideProject30

收納規劃APP系列 第 8

Day8:平面圖疊家具(一)

  • 分享至 

  • xImage
  •  

這個部份我大概卡了一、兩個禮拜,還沒卡完,雖然是30天連續發文的鐵人賽,但是 side project 應該會做超過30天。

把東西疊在一起之後開始出現問題,腦子開始有點混亂,看著存稿5、4、3...的減少,十分焦慮,只好讓大家看看壞掉的過程,以下是其中一次試誤。

今天的程式碼 Day8

為了圖層互動的功能把 html 標籤的 img 改成用 SVG 包起來

但平面圖圖層在拖曳的時候,一直有微妙的偏移,問題應該是在於我設定了三層 <svg>, <g>, 和 <image>,跟之前 image 只有一層不一樣,跟 Claude 反應之後,他是說確實可能是這個結構不一樣,所以座標系統也不一樣,之前的拖曳計算基於容器的座標系統,不是基於 SVG 座標系統轉換。

所以

  • 加上了 screenToSVGPoint 方法,用於將螢幕座標轉換為 SVG 座標。
  • mousedownmousemovemouseup 事件中,使用 screenToSVGPoint 來計算 SVG 座標系中的位置。
  • 更新了 updateTransform 方法,直接在 transform 字串中應用 panXpanY
  • 在除錯資訊中添加了 SVG 座標。

SVG在工作上比較少遇到,所以有點鴨子聽雷,總之是會正常平移了

floor-plan.component.html

<div class="svg-container" #container>
  <svg #svgElement [attr.viewBox]="viewBox">
      <g [attr.transform]="transform">
          <image [attr.href]="svgHref" />
      </g>
  </svg>
  <div #debugIndicator class="debug-indicator"></div>
</div>
<div class="debug-info">
  Mouse position: ({{debugInfo.x | number:'1.0-0'}}, {{debugInfo.y | number:'1.0-0'}})
</div>
<div class="controls">
  <button (click)="zoomIn()">放大</button>
  <button (click)="zoomOut()">縮小</button>
  <button (click)="reset()">重置</button>
  <button (click)="rotateClockwise()">順時針旋轉90°</button>
  <button (click)="rotateCounterclockwise()">逆時針旋轉90°</button>
</div>

floor-plan.component.scss

.svg-container {
  width: 100%;
  height: 75vh;
  border: 1px solid #ccc;
  overflow: hidden;
  position: relative;
}

svg {
  width: 100%;
  height: 100%;
}
.controls {
  margin-top: 10px;
}
.debug-indicator {
  position: absolute;
  width: 10px;
  height: 10px;
  background-color: red;
  border-radius: 50%;
  pointer-events: none;
}

.debug-info {
  position: absolute;
  bottom: 10px;
  left: 10px;
  background-color: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 5px;
  font-size: 12px;
}

floor-plan.component.ts

import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  OnDestroy,
  OnInit,
  Output,
  ViewChild,
} from '@angular/core';
import {
  fromEvent,
  Subject,
  Subscription,
  takeUntil,
  throttleTime,
} from 'rxjs';
import { ZoomService } from './zoom.service';

@Component({
  selector: 'app-floor-plan',
  templateUrl: './floor-plan.component.html',
  styleUrls: ['./floor-plan.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FloorPlanComponent implements OnInit, AfterViewInit, OnDestroy {
  @ViewChild('svgElement', { static: true })
  svgElement!: ElementRef<SVGSVGElement>;
  @ViewChild('container') container!: ElementRef<HTMLDivElement>;
  @ViewChild('debugIndicator') debugIndicator!: ElementRef<HTMLDivElement>;
  @Output() svgReady = new EventEmitter<SVGSVGElement>();
  @Output() scaleChanged = new EventEmitter<number>();
  @Output() transformChanged = new EventEmitter<{
    zoom: number;
    panX: number;
    panY: number;
    rotation: number;
  }>();
  @Output() svgDimensionsChanged = new EventEmitter<{
    width: number;
    height: number;
  }>();

  svgHref = 'assets/floor-plan.svg';
  viewBox = '0 0 100 100';

  private originalWidth = 100;
  private originalHeight = 100;
  private zoom = 1;
  private panX = 0;
  private panY = 0;
  private rotation = 0;
  private resizeSubscription?: Subscription;
  private destroy$ = new Subject<void>();
  debugInfo = { x: 0, y: 0, svgX: 0, svgY: 0 };

  get transform(): string {
    return this.zoomService.calculateTransform(
      this.zoom,
      this.panX,
      this.panY,
      this.rotation,
      this.originalWidth,
      this.originalHeight
    );
  }

  constructor(
    private zoomService: ZoomService,
    private cd: ChangeDetectorRef
  ) {}

  // Lifecycle methods
  ngOnInit(): void {
    this.loadSvgDimensions();
  }

  ngAfterViewInit(): void {
    this.setupZoom();
    this.setupResizeListener();
    this.svgReady.emit(this.svgElement.nativeElement);
    this.scaleChanged.emit(this.zoom);
  }

  ngOnDestroy(): void {
    this.resizeSubscription?.unsubscribe();
    this.destroy$.next();
    this.destroy$.complete();
  }

  // Public methods
  getSvgDimensions() {
    return {
      original: { width: this.originalWidth, height: this.originalHeight },
      display: this.getDisplayDimensions(),
    };
  }

  zoomIn(): void {
    this.zoom = this.zoomService.zoomIn(this.zoom);
    this.updateTransform();
  }

  zoomOut(): void {
    this.zoom = this.zoomService.zoomOut(this.zoom);
    this.updateTransform();
  }

  reset(): void {
    this.zoom = 1;
    this.panX = 0;
    this.panY = 0;
    this.rotation = 0;
    this.updateTransform();
  }

  rotateClockwise(): void {
    this.rotation = (this.rotation + 90) % 360;
    this.updateTransform();
  }

  rotateCounterclockwise(): void {
    this.rotation = (this.rotation - 90 + 360) % 360;
    this.updateTransform();
  }

  // Private methods
  private loadSvgDimensions(): void {
    const img = new Image();
    img.onload = () => {
      this.originalWidth = img.width;
      this.originalHeight = img.height;
      this.viewBox = `0 0 ${this.originalWidth} ${this.originalHeight}`;
      this.updateDisplayDimensions();
      this.svgDimensionsChanged.emit({
        width: this.originalWidth,
        height: this.originalHeight,
      });
      this.cd.markForCheck();
    };
    img.src = this.svgHref;
  }

  private updateDisplayDimensions(): void {
    if (this.svgElement?.nativeElement) {
      const svg = this.svgElement.nativeElement;
      const displayWidth = svg.width.baseVal.value;
      const displayHeight = svg.height.baseVal.value;
      console.log(`Display dimensions: ${displayWidth}x${displayHeight}`);
    }
  }

  private setupZoom(): void {
    const element = this.container.nativeElement;
    let isDragging = false;
    let startX = 0;
    let startY = 0;

    fromEvent<MouseEvent>(element, 'mousedown')
      .pipe(takeUntil(this.destroy$))
      .subscribe((event) => {
        isDragging = true;
        const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
        startX = svgPoint.x - this.panX;
        startY = svgPoint.y - this.panY;
        console.log('Mousedown:', {
          startX,
          startY,
          panX: this.panX,
          panY: this.panY,
        });
        event.preventDefault();
      });

    fromEvent<MouseEvent>(document, 'mousemove')
      .pipe(takeUntil(this.destroy$), throttleTime(16))
      .subscribe((event) => {
        if (!isDragging) return;
        event.preventDefault();
        requestAnimationFrame(() => {
          const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
          this.panX = svgPoint.x - startX;
          this.panY = svgPoint.y - startY;
          console.log('Mousemove:', {
            clientX: event.clientX,
            clientY: event.clientY,
            svgX: svgPoint.x,
            svgY: svgPoint.y,
            panX: this.panX,
            panY: this.panY,
          });
          this.updateTransform();
          this.updateDebugInfo(event);
        });
      });

    fromEvent<MouseEvent>(document, 'mouseup')
      .pipe(takeUntil(this.destroy$))
      .subscribe((event) => {
        if (!isDragging) return;
        isDragging = false;
        const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
        this.panX = svgPoint.x - startX;
        this.panY = svgPoint.y - startY;
        console.log('Mouseup:', {
          clientX: event.clientX,
          clientY: event.clientY,
          svgX: svgPoint.x,
          svgY: svgPoint.y,
          panX: this.panX,
          panY: this.panY,
        });
        this.updateTransform();
        this.updateDebugInfo(event);
      });

    fromEvent<WheelEvent>(element, 'wheel', { passive: true })
      .pipe(takeUntil(this.destroy$))
      .subscribe((event) => {
        const rect = element.getBoundingClientRect();
        const offsetX = event.clientX - rect.left;
        const offsetY = event.clientY - rect.top;

        const prevZoom = this.zoom;
        const delta = event.deltaY > 0 ? 0.9 : 1.1;
        this.zoom = this.zoomService.zoomTo(this.zoom, delta);

        // 調整 panX 和 panY 以保持縮放點不變
        this.panX -= (offsetX / prevZoom - offsetX / this.zoom) * this.zoom;
        this.panY -= (offsetY / prevZoom - offsetY / this.zoom) * this.zoom;

        this.updateTransform();
      });
  }
  private screenToSVGPoint(screenX: number, screenY: number): DOMPoint {
    const svg = this.svgElement.nativeElement;
    const pt = svg.createSVGPoint();
    pt.x = screenX;
    pt.y = screenY;
    return pt.matrixTransform(svg.getScreenCTM()?.inverse());
  }

  private updateTransform(): void {
    const transform = `translate(${this.panX}, ${this.panY}) scale(${this.zoom}) rotate(${this.rotation})`;
    this.scaleChanged.emit(this.zoom);
    this.transformChanged.emit({
      zoom: this.zoom,
      panX: this.panX,
      panY: this.panY,
      rotation: this.rotation,
    });
    console.log('Transform updated:', {
      transform,
      zoom: this.zoom,
      panX: this.panX,
      panY: this.panY,
      rotation: this.rotation,
    });
    this.cd.markForCheck();
  }

  private updateDebugInfo(event: MouseEvent): void {
    const rect = this.container.nativeElement.getBoundingClientRect();
    const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
    this.debugInfo = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
      svgX: svgPoint.x,
      svgY: svgPoint.y,
    };
    this.debugIndicator.nativeElement.style.left = `${this.debugInfo.x}px`;
    this.debugIndicator.nativeElement.style.top = `${this.debugInfo.y}px`;
    console.log('Debug info:', this.debugInfo);
    this.cd.markForCheck();
  }

  private setupResizeListener(): void {
    this.resizeSubscription = fromEvent(window, 'resize').subscribe(() => {
      this.reset();
    });
  }

  private getDisplayDimensions() {
    if (this.svgElement?.nativeElement) {
      const svg = this.svgElement.nativeElement;
      return {
        width: svg.width.baseVal.value,
        height: svg.height.baseVal.value,
      };
    }
    return { width: 0, height: 0 };
  }
}

zoom.service.ts

處理ZoomTo、In、Out功能用的service

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class ZoomService {
  private minZoom = 0.1;
  private maxZoom = 10;

  constructor() {}

  zoomTo(currentZoom: number, delta: number): number {
    const newZoom = currentZoom * delta;
    return Math.max(this.minZoom, Math.min(this.maxZoom, newZoom));
  }

  zoomIn(currentZoom: number): number {
    return this.zoomTo(currentZoom, 1.2);
  }

  zoomOut(currentZoom: number): number {
    return this.zoomTo(currentZoom, 0.8);
  }

  calculateTransform(
    zoom: number,
    panX: number,
    panY: number,
    rotation: number,
    width: number,
    height: number
  ): string {
    const centerX = width / 2;
    const centerY = height / 2;
    return `
      translate(${panX + centerX}, ${panY + centerY})
      scale(${zoom})
      rotate(${rotation})
      translate(${-centerX}, ${-centerY})
    `;
  }
}


然後copy一個正方形出來瞎搞,跟之前的矩形元件基本上一樣

主要就是多兩個方法,一個是固定正方形的方法、一個是跟著畫面縮放的方法,其他微調跟完整的程式碼,進前面提供的 Day8

square.component.ts

  fixPosition() {
    console.log('squire fix');
    this.isFixed = true;
    const target = document.querySelector('.target') as HTMLElement;
    console.log('target', target);
    if (target) {
      this.originalTransform = window.getComputedStyle(target).transform;
    }
  }

  private updateWithFloorPlanTransform() {
    if (this.isFixed) {
      const target = document.querySelector('.target') as HTMLElement;
      if (target) {
        const { zoom, panX, panY, rotation } = this.floorPlanTransform;
        const matrix = new DOMMatrix(this.originalTransform);
        const scale = zoom;
        const translateX = panX + matrix.e * scale;
        const translateY = panY + matrix.f * scale;
        const newTransform = `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotation}deg)`;
        target.style.transform = newTransform;
      }
    }
  }

square.component.scss

還有Scss需要調整

.target {
  width: 100px;
  height: 100px;
  background-color: rgba(76, 175, 80, 0.7);
  color: white;
  display: flex;
  justify-content: center;
  align-items: center;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
  cursor: move;
  position: absolute;
}

app.component.html

最後是操作層,要傳送兩個元件的資料,變形、旋轉、移動跟固定

<div class="svg-container mt-5">
  <app-floor-plan
    (transformChanged)="onFloorPlanTransformChanged($event)"
  ></app-floor-plan>
  <div class="overlay-container">
    <app-square [floorPlanTransform]="floorPlanTransform"></app-square>
  </div>
</div>
<button class="mt-5" (click)="fixSquarePosition()" class="mt-5">
  Fix Square Position
</button>

app.component.scss

  • {
    pointer-events: auto;
    }

上面這個要記得,不然碰不到正方形元件,不然就是碰到了無法變形旋轉,我也還沒想到更好的寫法,先這樣

.svg-container {
  position: relative;
  width: 100%;
  height: 75vh;
}

.overlay-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;

  // 允許 SquareComponent 接收鼠標事件
  > * {
    pointer-events: auto;
  }
}

// 確保 FloorPlanComponent 在 SquareComponent 下方
app-floor-plan {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
}

// 確保 SquareComponent 在 FloorPlanComponent 上方
app-square {
  position: absolute;
  z-index: 2;
}

.mt-5{
  margin-top: 5rem;
}

app.component.ts

import { Component, ViewChild, AfterViewInit, ChangeDetectorRef } from '@angular/core';
import { SquareComponent } from './square/square.component';
import { FloorPlanComponent } from './floor-plan/floor-plan.component';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent  {
  @ViewChild(SquareComponent, { static: false }) squareComponent!: SquareComponent;
  @ViewChild(FloorPlanComponent, { static: false }) floorPlanComponent!: FloorPlanComponent;
  floorPlanTransform = { zoom: 1, panX: 0, panY: 0, rotation: 0 };

  constructor(private cdr: ChangeDetectorRef) {}
  ngAfterViewInit() {
    this.cdr.detectChanges();
  }

  onFloorPlanTransformChanged(transform: { zoom: number; panX: number; panY: number; rotation: number }) {
    this.floorPlanTransform = transform;
  }

  fixSquarePosition() {
    if (this.squareComponent) {
      this.squareComponent.fixPosition();
    } else {
      console.error('Square component is not initialized');
    }
  }
}

以上的程式碼是一張平面圖釘上家具之後,家具定位錯誤還會四處飛的例子
簡單說就是在廁所釘上馬桶之後,旋轉平面圖,馬桶會跑到客廳
或是放大之後馬桶直接先離開平面圖這樣的效果
如果拖曳的話可以飛更遠,十分刺激

推測錯誤是中心定位或是座標系統的關係
不然就是打掉重練,多試試看幾種寫法


上一篇
Day7:加入平面圖
下一篇
Day9:平面圖疊家具(二)
系列文
收納規劃APP32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言